Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

16장. 인터페이스

15장에서 타입에 메서드를 묶는 법을 배웠다. 이제 “어떤 타입이든 좋다, 다만 이런 동작은 할 줄 알아야 한다” 는 조건을 표현하는 방법을 익힌다.

그것이 인터페이스다.

Go 의 인터페이스는 다른 언어와 결이 조금 다르다. 이 장 끝에서 그 차이를 분명히 알게 된다.

목표:

  • 인터페이스의 정의와 의미 이해
  • 암묵적 구현 방식 익히기
  • 다형성 코드를 직접 짜 보기
  • 빈 인터페이스(any)와 그 함정 알기
  • 타입 단언과 타입 스위치 사용

16.1 인터페이스란

인터페이스는 메서드 시그니처의 집합 이다.

“이런 이름과 시그니처의 메서드를 가지고 있는 모든 타입” 을 한 단어로 묶어 부르고 싶을 때 쓴다.

예를 들어 “Area() 메서드를 가진 모든 타입” 을 Shape 라고 부르고 싶다고 하자.

type Shape interface {
    Area() float64
}

이제 Shape 라는 이름은 “Area() float64 메서드를 가진 모든 타입의 별명” 이 된다.

왜 이게 필요한가

함수가 여러 종류의 타입을 받고 싶을 때가 있다.

func printArea(s Shape) {
    fmt.Println("넓이:", s.Area())
}

이 함수는 Circle 이든 Rectangle 이든 Triangle 이든 가리지 않는다. Area() 메서드만 있으면 받아 준다.

이게 다형성(polymorphism)이다. “구체적인 타입은 신경 끄고 동작만 보고 다루는 방식” 이라고 보면 된다.


16.2 인터페이스 정의

문법은 단순하다.

type 이름 interface {
    메서드1(매개변수) 반환타입
    메서드2(매개변수) 반환타입
    ...
}

표준 라이브러리의 예: Stringer

fmt 패키지에는 다음과 같은 인터페이스가 있다.

type Stringer interface {
    String() string
}

String() string 메서드를 가진 타입이라면 fmt.Println 이 그 메서드 결과를 출력에 사용한다.

type Person struct {
    Name string
    Age  int
}

func (p Person) String() string {
    return fmt.Sprintf("%s(%d세)", p.Name, p.Age)
}

func main() {
    p := Person{Name: "고길동", Age: 40}
    fmt.Println(p) // 고길동(40세)
}

Stringer 라는 이름을 어디에서도 명시하지 않았다. 그런데도 fmt.PrintlnString() 을 호출했다. 어떻게 가능할까. 다음 절이 그 답이다.


16.3 암묵적 구현 (Duck Typing)

Go 의 가장 큰 특징 중 하나가 여기에 있다.

“그 메서드를 가지고 있다면, 그 인터페이스를 만족한 것이다.”

따로 선언하지 않는다. 실제로 메서드 시그니처가 맞기만 하면 끝이다.

Java / C# 와 다른 점

Java 라면 명시적으로 선언해야 한다.

class Person implements Stringer { ... }

Go 에는 implements 키워드가 없다.

// Go 에선 그냥 메서드만 있으면 자동 성립
func (p Person) String() string { ... }

타입 정의 어디에도 Stringer 라는 단어가 없다. 그래도 컴파일러는 안다.

영어 속담 “오리처럼 걷고 오리처럼 운다면, 그건 오리다” 에서 따와 duck typing 이라고 부른다.

이게 왜 좋은가

  • 내가 만든 타입을 손대지 않고 남이 만든 인터페이스를 만족시킬 수 있다
  • 인터페이스를 나중에 추가해도 기존 코드를 수정할 필요가 없다
  • “메서드 셋이 맞느냐” 만 보면 되므로 설계가 가볍게 유지된다

16.4 다형성 사용 예: 도형

다양한 도형의 넓이를 출력하는 예제를 짜 보자.

인터페이스 정의

type Shape interface {
    Area() float64
}

두 타입 정의 및 메서드 구현

type Circle struct {
    R float64
}

func (c Circle) Area() float64 {
    return 3.14 * c.R * c.R
}

type Rectangle struct {
    W, H float64
}

func (r Rectangle) Area() float64 {
    return r.W * r.H
}

CircleRectangleArea() float64 를 가졌다. 즉, 둘 다 자동으로 Shape 다.

공통 함수

func PrintArea(s Shape) {
    fmt.Printf("%T 의 넓이: %.2f\n", s, s.Area())
}

%T 는 값의 실제 타입을 출력하는 포맷이다 (7장).

사용

shapes := []Shape{
    Circle{R: 3},
    Rectangle{W: 4, H: 5},
}

for _, s := range shapes {
    PrintArea(s)
}

출력:

main.Circle 의 넓이: 28.26
main.Rectangle 의 넓이: 20.00

서로 다른 타입을 한 슬라이스에 담을 수 있는 건 원소 타입이 인터페이스이기 때문이다.


16.5 빈 인터페이스 any / interface{}

0개 메서드의 인터페이스

type Anything interface{}

메서드 요구가 하나도 없다. “모든 타입이 자동으로 만족” 한다.

Go 1.18 부터 any 라는 별칭이 추가됐다.

var x any = 42        // int
x = "hello"           // string
x = []int{1, 2, 3}    // slice

anyinterface{} 는 완전히 같다. 새 코드에서는 any 를 쓰는 게 권장된다.

어디서 만나는가

  • JSON 디코딩 (값이 어떤 타입인지 미리 모를 때)
  • 가변 타입 인자 (fmt.Println(args ...any))
  • 컨테이너에 아무 타입이나 담아야 할 때
func print(args ...any) {
    for _, a := range args {
        fmt.Println(a)
    }
}

any 의 한계

편리하지만 대가가 있다.

  • 타입 안정성이 사라진다
  • 안에 든 값을 쓰려면 타입을 다시 알아내야 한다

다음 두 절에서 그 방법을 다룬다.


16.6 타입 단언 (Type Assertion)

any 에 담긴 값을 꺼내려면 “이거 사실은 string 이지?” 라고 단언해야 한다.

기본 형태

var i any = "hello"

s := i.(string)
fmt.Println(s) // hello

i.(string) 은 “i 가 가지고 있는 실제 타입이 string 이라고 단언한다” 는 뜻이다.

실패 시 동작

타입이 안 맞으면 패닉이 난다.

var i any = 42
s := i.(string)   // panic!

안전한 형태 (두 값 반환)

s, ok := i.(string)
if ok {
    fmt.Println("문자열:", s)
} else {
    fmt.Println("문자열이 아니다")
}

okfalse 면 패닉이 일어나지 않는다. 대신 s 는 string 의 제로값 "" 가 된다.

안전한 두 값 형태가 거의 모든 상황에서 정답이다.


16.7 타입 스위치 (Type Switch)

가능한 타입이 여러 개라면 타입 단언을 여러 번 늘어놓는 대신 타입 스위치를 쓴다.

func describe(i any) {
    switch v := i.(type) {
    case int:
        fmt.Println("int:", v*2)
    case string:
        fmt.Println("string:", strings.ToUpper(v))
    case bool:
        fmt.Println("bool:", !v)
    default:
        fmt.Printf("기타 (%T): %v\n", v, v)
    }
}

문법의 핵심은 i.(type) 이다. switch 안에서만 쓸 수 있는 특수 형태다.

case 안에서 v 의 타입이 자동으로 좁혀진다.

입력결과
describe(7)int: 14
describe("go")string: GO
describe(true)bool: false
describe(3.14)기타 (float64): 3.14

16.8 nil 인터페이스의 함정

Go 초보자가 자주 만나는 미묘한 버그가 있다.

인터페이스의 내부 구조

인터페이스 변수는 사실 두 가지 정보를 가진다.

슬롯의미
타입 (type)안에 들어 있는 값의 진짜 타입
값 (value)그 타입의 값

인터페이스가 nil 이 되려면 두 슬롯 모두 비어 있어야 한다.

잘못된 패턴

type MyError struct{ Msg string }

func (e *MyError) Error() string { return e.Msg }

func mayFail() error {
    var e *MyError = nil
    return e   // 함정!
}

func main() {
    err := mayFail()
    if err != nil {
        fmt.Println("에러:", err) // 이 분기로 들어간다!
    }
}

e 자체는 *MyError nil 인데도 err != nil 이 참이 된다.

왜냐하면 err (인터페이스) 의 슬롯이 이렇다.

슬롯
타입*MyError (nil 아님)
nil

타입 슬롯에 뭔가 들어 있기 때문에 인터페이스 자체는 nil 이 아니다.

올바른 패턴

에러가 없으면 nil 그 자체를 반환한다.

func mayFail() error {
    return nil   // 이렇게!
}

타입을 가진 변수를 거치지 말고 직접 nil 을 반환해야 한다.

이 함정은 21장의 에러 처리에서 다시 등장한다. “에러가 없으면 그냥 nil 을 반환한다” 만 지키면 대부분 피할 수 있다.


16.9 정리

이 장에서 살펴본 내용:

  • 인터페이스는 메서드 시그니처의 집합이다
  • “어떤 메서드를 가진 모든 타입” 을 묶어 다룬다
  • Go 는 암묵적 구현 — implements 키워드가 없다
  • 메서드 시그니처만 맞으면 자동으로 만족
  • 다형성을 통해 여러 타입을 한 함수로 처리할 수 있다
  • 빈 인터페이스 any 는 모든 타입을 받지만 타입 안정성이 약하다
  • 타입 단언 i.(T) 와 두 값 형태 v, ok := i.(T) 를 익혔다
  • 타입 스위치 switch v := i.(type) 로 여러 타입 분기
  • nil 인터페이스 함정: 타입 슬롯이 비어 있어야 nil 이다

인터페이스는 강력하다. 하지만 any 는 타입 정보를 잃는 단점이 있다.

다음 장에서는 그 단점을 해결하는 도구를 만난다. 타입 안정성을 유지하면서 여러 타입을 받는 함수를 만드는 방법 — 제네릭이다.